延續昨天 DAY 26 我與AI助手為虛擬舞者注入的「關節角度限制(Joint Limits)」功能,雖然成功解決了單一關節旋轉超限的幾何問題,但在實作與測試過程中,我們很快發現了兩個很棘手的痛點:
關節出現不符人體的極端角度:在隨意拉動手臂與肩膀關節球時,由於傳統 X, Y, Z 分軸獨立 Clamp 的限制,在球窩關節(如肩膀)極端角度下仍會算出解剖學上做不到的「卡死假姿勢」。
肢體穿膜:當手部快速進行複雜連招或時間軸插值播放時,雙手常常會硬生生插進軀幹、大腿,甚至「兩隻手互相穿透打架」。
為了讓虛擬模擬器的肢體運動真正接近物理現實,今天 DAY 27 我們要在系統中引入** 「Swing-Twist 橢圓錐關節限制」 與 「簡化膠囊體碰撞偵測」**!
對於肩膀與髖關節這類「球窩關節(Ball-and-Socket)」,如果僅採用傳統歐拉角 X, Y, Z 三軸獨立 clamp 限制,會因為無法處理軸向交會處而產生非自然的「矩形邊界」與脫臼假姿勢。3D Tutting 模擬器採用了動畫引擎經典的 Swing-Twist(擺動-自轉)分解法:
任何一個關節旋轉四元數 Q 都可被唯一分解為:
之間。將
的擺動向量投影至二維極座標平面 (x, y),此處的 x$代表前後甩動角度,y 代表左右張開角度。系統定義一個橢圓方程作為合法邊界:

為了在瀏覽器前端 60 FPS的即時渲染效能下解決肢體穿膜,我們放棄了計算量龐大的高面數 Mesh 碰撞,採用 膠囊體(Capsules)對球體(Spheres) 的簡化幾何逼近方案。
每一幀計算手掌球心P到各身體線段 AB 的最短距離:
當
時,即判定為穿膜發生,並計算出推離向量
:


這兩套系統體現了非常務實的 3D 前端工程思維:「用簡單、可解釋的幾何近似,去逼近複雜的人體限制,而不是硬上龐大的物理引擎。」
這種「先求穩定可用、再逐步逼近精確」的手法,正是我們開發即時互動 3D 工具時最核心的工程智慧!
## Prompt 1:Swing-Twist 橢圓錐關節限制(球窩關節專用)
在一個用 Three.js + Mixamo 骨架的人形姿勢編輯器裡,目前的關節角度限制(Joint Limits)
是對每個關節的 [x, y, z] 三軸各自獨立做矩形夾限(每軸各自 min~max,超出範圍就 clamp)。
這對絞鏈關節(手肘、膝蓋)沒問題,但對球窩關節(肩膀 rArm/lArm、髖 rUpLeg/lUpLeg)
是不準確的近似:矩形夾限允許「多軸同時貼到各自極限」的角落姿勢(例如 X=90°、Z=90° 同時
發生),但真實肩/髖關節可動範圍其實比較接近一個橢圓錐,兩軸同時拉到極限時骨骼、韌帶
會互相卡住,實際上到不了那個角落姿勢。
請只針對這 4 個球窩關節(rArm/lArm/rUpLeg/lUpLeg)實作 Swing-Twist 分解 + 橢圓錐夾限,
其餘關節維持原本矩形夾限不變:
【演算法】
1. 把該關節相對 rest pose 的旋轉,從現有的 [x,y,z] 歐拉角(度)轉成四元數 q。
2. 指定一個「骨骼長軸」方向(twistAxis,需可對外覆寫,因為不是每根骨骼的本地座標系
都一致;預設先假設本地 +Y,因為 Mixamo 命名慣例是骨骼局部 +Y 指向子關節)。
3. 把 q 分解成:
- twist:繞 twistAxis 的自轉分量(對應內旋/外旋)。
算法:dot = q.xyz 在 twistAxis 上的投影;twist = normalize(twistAxis*dot, q.w)。
- swing:垂直分量。swing = q * twist⁻¹(因為 q = swing * twist)。
4. twist 夾限:直接沿用「y 軸」的 min/max 做角度範圍夾限(twist 角度用
2*atan2(dot, q.w) 算出,不用先转四元数再转回,避免精度问题)。
5. swing 夾限:
- 先找出跟 twistAxis 垂直、互相正交的兩個單位向量 u, v,當作擺動平面的基底
(挑一個跟 twistAxis 不平行的參考向量做兩次 cross product 即可)。
- 把 swing 四元數換算成「擺動平面上的 2D 座標」:
theta = 2*acos(clamp(swing.w, -1, 1)) (總擺動角)
axis = normalize(swing.xyz)
sx = theta * dot(axis, u) sz = theta * dot(axis, v)
- 用「x 軸」「z 軸」的 min/max 當作橢圓錐兩個半軸的正負範圍(正負值分開取,
因為橢圓錐兩側可以不對稱):
- 兩軸都啟用限制時:做橢圓夾限 (sx/limX)² + (sz/limZ)² ≤ 1,超出時等比例縮回
(用 nx=sx/limX, nz=sz/limZ, r2=nx²+nz²,r2>1 時 scale = 1/sqrt(r2))。
- 只有一軸啟用時:退化成該軸單純的範圍夾限(另一軸完全不限制)。
- 兩軸都沒啟用時:swing 完全不動,維持自由擺動。
- 用夾完的 (sx, sz) 反推回新的 swing 四元數:旋轉向量 r = sx*u + sz*v,
長度=角度、方向(normalize後)=旋轉軸,setFromAxisAngle 組回四元數。
6. 組合:q_new = swing_clamped * twist_clamped,轉回 Euler("XYZ") 再換算成角度,
四捨五入到小數點一位,回傳新的 [x, y, z]。
【邊界與相容性要求】
- 每個關節的 x/y/z 三軸限制各自有獨立的 enabled 開關(跟現有矩形夾限系統共用同一份
JOINT_LIMITS 資料結構,不新增額外欄位)。
- 三軸都沒啟用時,必須直接原樣回傳輸入角度,完全不做四元數轉換(避免無謂的浮點誤差
累積,也維持跟舊版「未啟用即放行」行為一致)。
- 對外提供一個統一入口函式:傳入 jointKey + [x,y,z],內部判斷這個 key 是否屬於
Swing-Twist 關節集合,是的話走這套新邏輯,否則走原本逐軸矩形夾限,回傳新陣列、
不修改傳入的原陣列。
- 呼叫端(滑桿 UI、IK、隨機動作生成、關鍵影格播放)完全不用改,因為介面上三個滑桿
數字的意義不變,只是背後「合法範圍」從矩形變成橢圓錐。
- 骨骼長軸方向要做成可覆寫的設定(一個 key -> Vector3 的表),因為沒辦法保證每根
骨骼本地 +Y 都指向子關節;如果套用後轉起來視覺上「歪掉」(例如原本應該是內外旋的
軸變成在做抬手),開發者可以在這個表填入該骨骼實際的長軸方向修正,不用改動演算法。
- 熱路徑(每幀可能呼叫)要重複利用共用的暫存 Quaternion/Vector3 物件,不要每次呼叫
都 new,避免 GC 壓力。
用純 JavaScript(搭配 THREE.Quaternion / THREE.Vector3 / THREE.Euler)實作,
並附上關鍵步驟的中文註解說明每一段在做什麼、為什麼這樣分解。
Prompt 2:手部-身體簡化膠囊體碰撞偵測(防穿模回彈)
在一個用 Three.js + Mixamo 骨架的人形姿勢編輯器裡,使用者可以用滑桿、IK 拖曳、隨機動作
生成、關鍵影格播放等方式擺動手臂,這些操作都可能不小心讓手插進軀幹、腿或頭裡(穿模)。
請實作一套輕量的「手部-身體碰撞偵測 + 回彈」系統,讓手自動被推出來,而不是放任穿模。
【碰撞形狀簡化】
- 身體不用做精細網格碰撞,而是簡化成一組「膠囊體」:每個膠囊體由兩根骨骼的世界座標
連成一條線段,加上一個半徑,定義成 { boneA, boneB, radius } 的資料物件。
- 分成三組障礙物,共用同一套資料格式與同一套碰撞邏輯:
1. 軀幹:Hips→Spine→Spine1→Spine2→Neck,由下往上切成 4 段膠囊(半徑可以不同,
胸口一帶通常最寬)。
2. 腿部:左右大腿(UpLeg→Leg)、左右小腿(Leg→Foot)各一段,共 4 段。
3. 頭部:故意用「boneA === boneB」寫成線段長度為 0 的退化膠囊,因為「點到線段最近點」
的算法對長度趨近 0 的線段本來就會直接退化成回傳該點本身,等同於「點到球心距離」,
不用另外寫一套球體碰撞的程式碼,跟膠囊共用同一套判斷邏輯。
- 手掌本身視為一個球:球心=手掌骨世界座標,半徑為一個可調整的常數。
- 所有半徑都要能在 UI 用滑桿即時調整並存進 localStorage,下次開頁自動還原
(包含各段膠囊半徑、手掌球半徑),並提供「恢復預設值」的按鈕。
【每幀碰撞流程】
1. 只對「沒有開啟該手臂 IK」且「使用者目前沒有正在拖曳這條手臂鏈上任何關節」的手臂
做偵測(開了 IK 代表使用者已經用目標球明確指定手的位置,不跟它搶;正在拖曳中也要
避免手感被搶走)。
2. 取得該手掌骨的世界座標。
3. 對「軀幹+腿+頭」全部障礙物膠囊做一次迴圈:
- 用「點到線段最近點」算法(沿線段方向投影、夾在 [0,1] 之間)算出手掌球心到這段
膠囊中心線的最近點與距離。
- 穿模深度 = (該膠囊半徑 + 手掌球半徑) - 距離。
- 記錄穿模深度最大(最嚴重)的那一段,記下推出方向(從最近點指向手掌球心,
normalize;如果剛好在中心線上導致方向向量長度為 0,隨便挑一個固定方向避免除以零)
以及目標位置(沿推出方向,從最近點推到「剛好貼齊膠囊表面」的位置)。
4. 如果本幀確實有穿模,針對這條手臂鏈的「上臂+前臂」兩節骨骼,重用既有的通用 CCD
(Cyclic Coordinate Descent)IK 求解器,把手掌骨這個 effector 往「目標位置」解幾輪
(例如 3 輪),但把阻尼係數調低(例如 0.35,明顯低於一般 IK 用的阻尼),讓效果比較
像「頂住」而不是「瞬間被彈飛」。
5. 只推手臂的「上臂/前臂」兩節,刻意不動肩膀,這樣視覺上是手肘自然彎開,不會連帶整個
肩膀被拖著轉。
6. 解完之後要把手動調整過的骨骼角度同步寫回對應的 UI 狀態(例如滑桿顯示的角度值),
確保 CCD 直接改骨骼四元數後,介面上的數字不會跟實際姿勢脫節。
7. 身體(軀幹/腿/頭)在這個系統裡永遠是「固定障礙物」,只有手會被推開,不會反過來
影響身體其他部位的姿勢,避免這套系統跟腿部 IK、其他律動系統互相打架。
【額外功能:雙手互碰】
用獨立開關做一套「左右手掌互相碰撞」的版本,規則:
- 兩隻手掌都視為球,用「兩球心距離 < 兩球半徑之和」判斷是否穿模。
- 若某隻手已開 IK 或正被拖曳,視為使用者明確鎖定,這隻手在這裡不會被移動。
- 兩隻手都可動時,穿模量各退一半(雙方對稱互推);只有一隻可動時,把可動的那隻整個
推到「貼齊另一隻(固定)手掌表面」的位置;兩隻都被鎖定時完全不處理,尊重使用者刻意
讓兩手交疊的選擇。
- 同樣用「上臂+前臂」CCD、低阻尼的方式套用位移,不直接瞬移。
【除錯視覺化】
另外提供一個獨立開關(跟碰撞回彈是否啟用無關),把目前所有身體膠囊(用
CapsuleGeometry,沿兩骨骼世界座標中點擺放、Y 軸對齊 A→B 方向)跟手掌碰撞球用半透明
線框畫出來,方便使用者一邊調半徑滑桿一邊即時看到形狀對不對,不用真的先開回彈才能調。
【效能要求】
所有向量/四元數運算重複利用共用的暫存物件(不要每幀 new 新的 Vector3),因為這是
每幀執行的熱路徑;碰撞偵測跟視覺化 mesh 更新都要考慮到降頻(idle 時可以跳過)的
可能性,函式設計上不要假設一定每幀都會被呼叫。
用純 JavaScript + Three.js 實作,附上中文註解解釋每個設計取捨(例如為什麼只做
上臂/前臂、為什麼阻尼要調低、為什麼頭部用退化膠囊)。